# Worked example: an income statement with actuals, a forecast and annual columns

Quarterly calendar, actuals through 2026-Q2, forecast to 2027-Q4, years rolled up from the quarters.
Revenue, revenue growth, cogs, gross profit, gross margin, opex, operating income, operating margin.
The historical and forecast quarters of the same line item are governed by different rules.

## 1. Declare the model

```json
{
  "calendars": {
    "fy": {
      "granularity": "quarter", "start": "2025-Q1", "end": "2027-Q4", "rollups": ["year"],
      "properties": { "frame": { "type": "string", "default": "hist" } },
      "period_properties": { "2026-Q3..2027-Q4": { "frame": "fcst" } }
    }
  },
  "modules": {
    "is": {
      "name": "Income statement",
      "dims": ["time:fy"],
      "line_items": {
        "revenue": { "formulas": [
          { "scope": { "frame": "fcst" }, "formula": "PREV(revenue) * (1 + revenue_growth)" }
        ] },
        "revenue_growth": { "formulas": [
          { "scope": { "frame": "hist" }, "formula": "IF PREV(revenue) = 0 THEN BLANK ELSE revenue / PREV(revenue) - 1" },
          { "scope": { "time": "level = year" }, "formula": "IF PREV(revenue) = 0 THEN BLANK ELSE revenue / PREV(revenue) - 1" }
        ] },
        "cogs": { "formulas": [
          { "scope": { "frame": "fcst" }, "formula": "revenue * (1 - gross_margin)" }
        ] },
        "gross_profit": { "formulas": [{ "formula": "revenue - cogs" }] },
        "gross_margin": { "formulas": [
          { "scope": { "frame": "hist" }, "formula": "gross_profit / revenue" },
          { "scope": { "time": "level = year" }, "formula": "gross_profit / revenue" }
        ] },
        "opex": { "formulas": [
          { "scope": { "frame": "fcst" }, "formula": "PREV(opex) * (1 + 3%)" }
        ] },
        "operating_income": { "formulas": [{ "formula": "gross_profit - opex" }] },
        "operating_margin": { "time_aggregation": "formula", "formulas": [{ "formula": "operating_income / revenue" }] }
      }
    }
  },
  "views": {
    "income_statement": { "source": "is", "columns": ["time"], "line_item_axis": "rows", "header_props": { "time": ["frame"] }, "default": true }
  }
}
```

## 2. Land the actuals and the forecast assumptions

```json
[
  { "op": "set_values", "module": "is", "item": "revenue", "cells": [[["2025-Q1"], 100], [["2025-Q2"], 110], [["2025-Q3"], 120], [["2025-Q4"], 130], [["2026-Q1"], 140], [["2026-Q2"], 150]] },
  { "op": "set_values", "module": "is", "item": "cogs",    "cells": [[["2025-Q1"], 40], [["2025-Q2"], 44], [["2025-Q3"], 47], [["2025-Q4"], 51], [["2026-Q1"], 55], [["2026-Q2"], 58]] },
  { "op": "set_values", "module": "is", "item": "opex",    "cells": [[["2025-Q1"], 30], [["2025-Q2"], 31], [["2025-Q3"], 32], [["2025-Q4"], 33], [["2026-Q1"], 34], [["2026-Q2"], 35]] },
  { "op": "set_values", "module": "is", "item": "revenue_growth", "cells": [[["2026-Q3"], 0.05], [["2026-Q4"], 0.05], [["2027-Q1"], 0.04], [["2027-Q2"], 0.04], [["2027-Q3"], 0.03], [["2027-Q4"], 0.03]] },
  { "op": "set_values", "module": "is", "item": "gross_margin",   "cells": [[["2026-Q3"], 0.62], [["2026-Q4"], 0.62], [["2027-Q1"], 0.63], [["2027-Q2"], 0.63], [["2027-Q3"], 0.64], [["2027-Q4"], 0.64]] }
]
```

## 3. Read it back

finidb_query(module: is, columns: [time], line_item_axis: rows) — periods marked fcst are forecast,
bare years are the roll-up columns:

| line item | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2025 | 2026-Q1 | 2026-Q2 | 2026-Q3 | 2026-Q4 | 2026 | 2027-Q1 | 2027-Q2 | 2027-Q3 | 2027-Q4 | 2027 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| revenue | 100.0 | 110.0 | 120.0 | 130.0 | 460.0 | 140.0 | 150.0 | 157.5 | 165.4 | 612.9 | 172.0 | 178.9 | 184.2 | 189.8 | 724.9 |
| revenue_growth |  | 10.0% | 9.1% | 8.3% |  | 7.7% | 7.1% | 5.0% | 5.0% | 33.2% | 4.0% | 4.0% | 3.0% | 3.0% | 18.3% |
| cogs | 40.0 | 44.0 | 47.0 | 51.0 | 182.0 | 55.0 | 58.0 | 59.9 | 62.8 | 235.7 | 63.6 | 66.2 | 66.3 | 68.3 | 264.5 |
| gross_profit | 60.0 | 66.0 | 73.0 | 79.0 | 278.0 | 85.0 | 92.0 | 97.7 | 102.5 | 377.2 | 108.4 | 112.7 | 117.9 | 121.4 | 460.4 |
| gross_margin | 60.0% | 60.0% | 60.8% | 60.8% | 60.4% | 60.7% | 61.3% | 62.0% | 62.0% | 61.5% | 63.0% | 63.0% | 64.0% | 64.0% | 63.5% |
| opex | 30.0 | 31.0 | 32.0 | 33.0 | 126.0 | 34.0 | 35.0 | 36.1 | 37.1 | 142.2 | 38.2 | 39.4 | 40.6 | 41.8 | 160.0 |
| operating_income | 30.0 | 35.0 | 41.0 | 46.0 | 152.0 | 51.0 | 57.0 | 61.6 | 65.4 | 235.0 | 70.1 | 73.3 | 77.3 | 79.7 | 300.4 |
| operating_margin | 30.0% | 31.8% | 34.2% | 35.4% | 33.0% | 36.4% | 38.0% | 39.1% | 39.5% | 38.3% | 40.8% | 41.0% | 42.0% | 42.0% | 41.4% |

## What to notice

- **revenue** — history: actual, typed in; forecast: PREV(revenue) * (1 + revenue_growth); annual: sum of the quarters
- **revenue_growth** — history: revenue / PREV(revenue) - 1; forecast: your assumption, typed in; annual: same formula, prior year
- **cogs** — history: actual, typed in; forecast: revenue * (1 - gross_margin); annual: sum of the quarters
- **gross_profit** — history: revenue - cogs; forecast: revenue - cogs; annual: sum of the quarters
- **gross_margin** — history: gross_profit / revenue; forecast: your assumption, typed in; annual: same formula, annual totals
- **opex** — history: actual, typed in; forecast: PREV(opex) * (1 + 3%); annual: sum of the quarters
- **operating_income** — history: gross_profit - opex; forecast: gross_profit - opex; annual: sum of the quarters
- **operating_margin** — history: operating_income / revenue; forecast: operating_income / revenue; annual: same formula, annual totals

- One line item, two formulas. A rule carries a scope, and `{frame: hist}` and `{frame: fcst}` select the periods it governs. Where no rule matches, the cell is an input: that is how the actuals get in.
- `frame` is a property of the calendar, not a column of the model. Moving the boundary between actual and forecast is one call that sets it; every rule re-scopes itself.
- The drivers run backwards in history: growth and margin are computed from the actuals, and are yours to type in the forecast. The engine allows it because the two rules never govern the same period.
- The annual columns come from `rollups: [year]` on the calendar. Amounts sum. Ratios would be meaningless summed, so `revenue_growth` and `gross_margin` carry an explicit annual rule and `operating_margin` uses `time_aggregation: formula`, which re-evaluates it on the annual totals.
- Nothing here addresses a cell. Change one actual and only the cells that read it recompute; ask `explain` about any number and it names the rule, what it read and what depends on it.

finidb_list_errors returns [] for this model.
