Shipmind Labs

Why a quarterly margin cannot be a sum of monthly margins

· 9 min read

A financial model that rolls up correctly for revenue rolls up wrongly for everything derived from it. The quarter column in a spreadsheet holds whatever a person typed into it, usually a sum. A sum is the right answer for a flow, the wrong answer for a margin, and the wrong answer again for a cash balance.

We build period models often enough in client work that we pulled ours into a library and keep it public: finmodel. It builds statements from declarative specs instead of cells. The part worth walking you through here is the one that fixes the quarter column: the rollup belongs to the input lines alone, and every computed line is evaluated again over the rolled inputs.

The quarter column is a value, not a rule#

A spreadsheet year is twelve copies of the same arithmetic placed side by side, plus four or five summary columns. Each copy stands alone. Nothing in the file says the twelve revenue cells are one line, and nothing says the quarter cell comes from them by a rule rather than by a formula somebody dragged across. The file cannot tell you what gross margin depends on, and you cannot review it except one cell at a time.

That is where the margin bug lives. Sum twelve monthly gross margins and you get a number near twelve times the real one. Average them and you get a revenue-blind mean that disagrees with the year's own profit over its own revenue. Both cells look plausible, and neither one is the margin.

The same column breaks balances in the other direction. A closing cash balance summed over three months is three balances added together. What the quarter closes at is just what its third month closed at.

The rollup belongs to the inputs#

In finmodel a statement is a tree of frozen dataclasses: blocks group, input lines take values per period, computed lines carry a formula that names other lines by id. Construction validates the tree and parses every formula, so the dependency graph and the order the lines must be evaluated in both exist before you supply a single number.

python
from decimal import Decimal

from finmodel import (
    Block,
    ComputedLine,
    InputLine,
    PeriodicModel,
    Rollup,
    Rounding,
    Statement,
)

pnl = Statement(
    id="pnl",
    label="Profit and loss",
    rounding=Rounding(2),
    children=[
        Block(
            id="trading",
            label="Trading",
            children=[
                InputLine(id="revenue", label="Revenue"),
                InputLine(id="cogs", label="Cost of goods sold"),
            ],
        ),
        ComputedLine(
            id="gross_profit",
            label="Gross profit",
            formula="revenue - cogs",
        ),
        ComputedLine(
            id="gross_margin",
            label="Gross margin",
            formula="gross_profit / revenue",
        ),
    ],
)

pnl.evaluation_order
# ('gross_profit', 'gross_margin')

A PeriodicModel wraps that statement with two further declarations: how each input line spans a period, and which balances carry from one period into the next.

python
model = PeriodicModel(pnl)

model.rollup_of("revenue")
# <Rollup.SUM: 'sum'>

Rollup.SUM is the default, which is right for every flow, and the rollups mapping overrides it per line where the line is not a flow. What the constructor will not accept is a rollup on anything that is not an input line, because a computed line does not have a rollup. It has a formula:

python
PeriodicModel(pnl, rollups={"gross_margin": Rollup.LAST})
# SpecError: only input lines roll up, and 'gross_margin' is computed

That error is the whole design in one sentence. There is no span behaviour to choose for a margin, because the margin is not carried across the boundary at all. It gets recomputed on the other side of it.

Computed lines are evaluated again#

run takes a Timeline and the values and evaluates the months in order. Then the roll step builds each quarter from its months and the year from the quarters: every input line goes through a combine, where SUM adds the amounts and FIRST and LAST take an edge. Only once the span's inputs exist does the statement's own evaluate run again over them.

So the year's gross profit is the year's revenue minus the year's cost of goods sold, and the year's gross margin is that profit over that revenue. A ratio is not a sum of ratios, and not an average of them either, because it was never rolled in the first place.

The clearest demonstration is a line that is not a flow at all. A headcount does not sum over a quarter. The quarter's headcount is the one it ended with.

python
staff = Statement(
    id="staff",
    label="Staff",
    children=[
        InputLine(id="headcount", label="Headcount"),
        ComputedLine(id="reported", label="Reported", formula="headcount"),
    ],
)

model = PeriodicModel(staff, rollups={"headcount": Rollup.LAST})
values = {
    month.id: {"headcount": Decimal(number)}
    for number, month in enumerate(timeline.months, start=1)
}
results = model.run(timeline, values)

results.amount("2026-Q1", "headcount")
# Decimal('3')
results.amount("2026", "reported")
# Decimal('12')

Twelve months carrying 1 through 12. The first quarter takes 3, the last month in it. The year takes 12, not because the computed line was rolled, but because the year's headcount input rolled to 12 and reported was evaluated again on top of it. Rolled like a flow, reported would have read 78.

The evaluator also carries a NotApplicable amount for figures a period does not have, and SUM returns the first one it meets rather than treating it as zero. An unknown stays an unknown on the way up, instead of turning into a number nobody asked for.

A balance takes the edges, and its movement is a proof#

A Carry names an opening line, a closing line, and optionally the movement between them. Declaring one does three things at once.

It fixes the rollups. Construction claims FIRST for the opening and LAST for a closing that is an input line, and a stated rollup that contradicts the carry is refused rather than quietly overridden.

It wires the periods. An opening is never read from its own period's values (supplying one there is a SpecError), it is the previous period's closing, with the first month taking the opening mapping handed to run, or Decimal(0).

And when a movement is named, the identity becomes an assertion that runs everywhere:

python
from finmodel import Carry

model = PeriodicModel(
    cash,
    carries=[
        Carry(
            opening="opening_cash",
            closing="closing_cash",
            movement="net_cash_flow",
        )
    ],
)
results = model.run(
    timeline, values, opening={"opening_cash": Decimal("1000.00")}
)

print(results.proofs("2026")[0])
# closing_cash over 2026: 1000.00 + 7320.00 = 8320.00 (holds)

That is sixteen proofs for a twelve-month timeline with one carry: one per month, one per quarter, one for the year. A proof whose opening plus movement is not its closing, or that touches a NotApplicable, raises IdentityError with the proof attached, and the proof knows the period it broke in.

python
import pytest

from finmodel import IdentityError

model = cash_model("opening_cash + net_cash_flow + 1")
with pytest.raises(IdentityError) as error:
    model.run(timeline, monthly(timeline))

error.value.proof.period.id
# '2026-01'

This is the part the spreadsheet cannot do at all. Rollup rules are only as good as their agreement with the model's own arithmetic, and the movement is what makes that agreement checkable on every span rather than eyeballed on one. A rollup that stops reconciling comes back to you as a period id, which is a bug report. A spreadsheet comes back as a balance sheet that is off by something, and a week of bisecting columns.

Above the carries sit the checks. Check.reconciles ties a balance sheet's cash line to what the cash flow closes at, and a Checklist runs the whole set over one period's amounts, with verify refusing the period and naming it through where. Carries prove the model is internally consistent across time; checks state what the statements must agree on with each other.

What the model refuses#

The model says The error reads
a rollup on a computed line only input lines roll up, and 'gross_margin' is computed
a rollup outside the enum unknown rollup 'middle' for line 'receipts'
one id opening and closing a period balance 'opening_cash' cannot open and close the same period
the same balance in two carries balance 'closing_cash' is carried twice
an opening that is not an input line opening balance 'opening_cash' must be an input line
an opening supplied with a month's values opening balance 'opening_cash' is carried between periods, not given for 2026-03
an opening for a line that carries none opening balances given for lines that carry none: receipts

run also requires values for exactly the twelve month ids. A missing month and a month outside the timeline are both refused by id, so a year you assembled from a partial extract fails at the model rather than producing a quarter that is quietly short one month.

What it costs to run#

Sixteen evaluations for a year with quarters: twelve months, four quarters, one year, over a statement whose formulas were parsed once at construction. Proof construction is per period per carry with a movement. The arithmetic is Decimal with per-line rounding, so the cost is correctness-shaped rather than fast-shaped, which is the trade we want in a model that someone will sign something against.

The declarations are the real cost, and you pay them once. Every input line that is not a flow needs its rollup stated, and every balance that crosses a boundary needs its carry. In exchange, nobody decides again what a quarter column should contain. finmodel has no file format of its own either: a spec is ordinary Python objects, so loading one from JSON, YAML or a database is a loader you write, and a loaded spec is validated exactly like a hand-written one. The library is pre-alpha, and the API still moves.

Close#

The quarter column was probably never the problem. The absence of a rule behind it was. Give the rollup to the inputs, re-derive everything computed on the other side of the boundary, state the identity you expect to hold, and a summed margin stops being something a reviewer has to catch.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com