Reconcile every debt, cash, lease, interest, tax, and D&A component before interpreting a net-leverage multiple.
The decision this tutorial makes visible
Net leverage is widely used in issuer and covenant communication, yet both net debt and EBITDA are definition-sensitive non-GAAP constructions.
The precise question is: How large is selected net debt relative to the package's reconciled period EBITDA?
An analyst needs to see what changed in the business; a builder needs every source fact, clock, unit, sign, average, adjustment, and reason code needed to reproduce the diagnostic.
Intuition before notation
The numerator is a balance-date financing stock and the denominator is a period performance flow. Lease inclusion and cash eligibility must be visible, and nonpositive EBITDA blocks the multiple.
This package selects one explicit statement-ratio convention and compares material alternatives. It does not label issuer-defined measures as universal or hide non-meaningful denominator states.
Scope and nearby methods
Canonical net debt = current borrowings + noncurrent borrowings + optionally lease liabilities − cash and cash equivalents; EBITDA bridges from net income by adding interest, tax, and D&A.
| Variant | Definition | Best use | Main limitation |
|---|---|---|---|
| Package net-income bridge | NI + interest + tax + D&A | Transparent educational reconciliation | Not adjusted EBITDA |
| Issuer adjusted EBITDA | Package-specific reconciliation | Understanding management reporting | Peer definitions differ |
| Covenant net leverage | Credit-agreement debt and EBITDA | Covenant compliance | Cannot infer from public shorthand |
What is sourced, selected, synthetic, and derived
| Role | Material claim | Evidence | Boundary |
|---|---|---|---|
| Sourced fact | Non-GAAP measures can mislead when labels or adjustments obscure their construction; SEC staff describes EBIT and EBITDA from GAAP net income and expects reconciliation and comparable-GAAP prominence. | S1 and supporting sources | It governs issuer disclosure; it does not make one analytical ROIC, net-debt, or leverage convention universal. |
| Implementation choice | Canonical net debt = current borrowings + noncurrent borrowings + optionally lease liabilities − cash and cash equivalents; EBITDA bridges from net income by adding interest, tax, and D&A. | Frozen package definition contract | Nearby variants remain named and separate. |
| Synthetic teaching input | All company amounts, periods, scenarios, and calculated teaching paths are repository-authored synthetic data. | datasets/canonical-input.json and scenario-results.json | No value is represented as a live provider observation or filed issuer fact. |
| Author-derived calculation | Synthetic net income 120 + interest 45 + tax 35 + D&A 80 gives EBITDA 280. Debt 60 + 600 + leases 90 − cash 150 gives net debt 600, so net debt/EBITDA is 2.142857×. | Formula, shared fixture, independent arithmetic, and Python/TypeScript parity | Arithmetic fidelity does not prove an analytical or investment conclusion. |
| Scope boundary | The output does not establish investment quality, solvency, valuation, peer superiority, forecast accuracy, profitability, covenant compliance, or investment advice. | No empirical or advisory claim is tested | Use the result as one documented diagnostic. |
Primary regulator, accounting-body, institution, paper, and filed-entity sources establish reporting and definition boundaries. The fixtures, calculations, scenarios, and conclusions about those fixtures are repository-authored and synthetic.
Formula, symbols, and numerical policy
EBITDA = Net Income + Interest + Tax + D&A; Net Debt = Borrowings + selected Lease Liabilities − Cash & Equivalents; Ratio = Net Debt / EBITDA
| Symbol | Meaning | Unit | Policy |
|---|---|---|---|
| E | Package EBITDA | currency/period | Net income bridge |
| D | Selected gross debt | currency at date | Lease policy retained |
| C | Eligible cash and cash equivalents | currency at date | No silent marketable/restricted cash |
| ND | Net debt D − C | currency | May be negative |
- Use one currency and scale for every amount in a calculation; ratios are dimensionless and days use the declared period day count.
- Do not round inputs or intermediate averages; round displayed percentages, days, and multiples only after calculation.
- Return a diagnostic null for structurally valid but economically uninterpretable denominators; reject malformed schemas and non-finite numbers.
- Preserve filed signs in common-size statements and normalize expense/debt signs explicitly for ratios that require positive denominators.
Read the formula in the same order as the algorithm. Validate identity, ordering, units, and supported state first. Apply the selected denominator, equality, and mapping rules second. Calculate with unrounded numeric values. Round only at the declared presentation boundary, and preserve null as a diagnostic rather than coercing it to zero.
Build the algorithm
- Validate one LTM/annual earnings period and the matching balance date.
- Add interest, tax, and D&A to net income.
- Add selected debt components and optionally leases, then subtract eligible cash equivalents.
- Divide only when EBITDA is positive and retain the lease policy.
Production-minded operational checklist
- Freeze earnings period and debt date
- Reconcile EBITDA to net income
- Define debt and lease scope
- Define eligible cash and covenant differences
A precise ratio is unsafe when its framework, period, unit, source fact, numerator, denominator, average, sign, or adjustment policy is ambiguous. Stop and map the evidence before calculating.
Worked synthetic example
The canonical fixture is synthetic teaching data, not a filed issuer statement or provider observation. Its primary author-derived output,
net_debt_to_ebitda, is 2.142857142857. The complete input and output
are in datasets/canonical-input.json and datasets/expected-output.json.
Synthetic net income 120 + interest 45 + tax 35 + D&A 80 gives EBITDA 280. Debt 60 + 600 + leases 90 − cash 150 gives net debt 600, so net debt/EBITDA is 2.142857×.
Counterfactual checkpoint
Lease-policy switch. Exclude lease liabilities while holding every filed amount constant. The output changes because The label alone does not reveal whether leases are inside debt.
The structured result retains state and diagnostics in addition to the primary number. That makes the calculation independently reviewable and prevents a partial, null, rejected, or definition-bounded outcome from being mistaken for an unqualified value.
Boundary and counterexample workbook
The playground computes every scenario at 61 deterministic parameter states.
The table uses the declared focus step and states whether that focus reproduces
the canonical fixture. The full state ledger and compressed transition
segments are in datasets/scenario-results.json.
| Scenario | Review focus | Purpose | State | Primary output | Diagnostic | Decision segments |
|---|---|---|---|---|---|---|
| Canonical cash sweep | Step 30 · canonical fixture | Move eligible cash through canonical net debt. | net-debt | 2.14× | net-debt · net-debt-divided-by-ebitda | 1 |
| Borrowing sensitivity | Step 30 · canonical fixture | Change noncurrent borrowings. | net-debt | 2.14× | net-debt · net-debt-divided-by-ebitda | 1 |
| Lease-policy comparison | Step 30 · canonical fixture | Compare the same statement with leases included and excluded. | net-debt | 2.14× | net-debt · net-debt-divided-by-ebitda | 1 |
| EBITDA earnings bridge | Step 30 · canonical fixture | Move net income while other add-backs stay fixed. | net-debt | 2.14× | net-debt · net-debt-divided-by-ebitda | 1 |
| D&A sensitivity | Step 30 · canonical fixture | Change depreciation and amortization. | net-debt | 2.14× | net-debt · net-debt-divided-by-ebitda | 1 |
| Net-cash boundary | Step 30 · comparison focus | Cross net debt equal to zero. | net-debt | 0.18× | net-debt · net-debt-divided-by-ebitda | 2 |
| Nonpositive EBITDA | Step 30 · comparison focus | Cross the package's meaningful-denominator gate. | net-debt | 8.57× | net-debt · net-debt-divided-by-ebitda | 2 |
These rows are not backtest observations. They are controlled counterexamples that expose how one driver changes the state, output, or reason code while the rest of the contract stays fixed.
Visualize the boundary
Open this SVG at full size, or use the guided playground to compare the seven topic-specific canonical, boundary, policy, and failure scenarios.
The Mermaid flow answers where the selected calculation sits in the processing sequence. The SVG keeps the formula, output, decision boundary, and invariant visible together. The lab lets the reader step through the same structured states without changing the underlying definition.
Five coordinated teaching views
The hero fixes the practitioner question and the builder contract before any ratio is interpreted.
The formula view keeps units, policies, and the material denominator boundary beside the symbols.
The worked-example view separates synthetic input, author-derived output, invariant, and counterfactual.
The comparison view prevents same-label measures from being treated as interchangeable.
The lineage view shows where entity, period, framework, unit, sign, and definition decisions enter.
| Asset | Learning purpose | Static fallback |
|---|---|---|
| Article hero | Question and learning contract | Embedded SVG text |
| Formula anatomy | Units, policies, denominator gate | Symbol table |
| Worked example | Independent arithmetic and invariant | Fixture JSON |
| Definition comparison | Variant selection | Comparison table |
| Evidence lineage | Source-to-output audit path | Claim ledger |
| Guided playground | Canonical, boundary, comparison, and failure states | Scenario ledger |
Implementation walkthrough
The Python and TypeScript references validate the same JSON contract, retain components and reason codes, calculate at full precision, and are compared against shared expected output plus 427 scenario states.
The main implementation branches are:
- include leases = true — Add lease liabilities to debt, because Package policy makes lease financing visible.
- net debt < 0 — Retain negative multiple, because Eligible cash exceeds selected debt.
- EBITDA <= 0 — Return not-meaningful, because The multiple reverses or loses leverage interpretation.
Neither reference silently fetches data, mutates caller-owned inputs outside the declared engine behavior, guesses hidden state, or substitutes a provider default. Shared JSON fixtures make value, null, state, and reason-code drift visible across languages.
Testing and validation
Definition tests compare every canonical field, reject malformed state, and exercise the material boundary. Family validation recomputes every playground state from the reference function. Independent arithmetic is recorded beside the fixture rather than inferred only from implementation output.
The audit must preserve these invariants:
- Gross debt reconciles to the explicitly selected debt components.
- Net debt equals gross debt minus eligible cash and can be negative.
- Nonpositive EBITDA returns a null multiple even when net debt is positive.
- The output retains components, state, and a reason code rather than publishing an unexplained scalar.
Passing definition and parity checks proves that the implementation matches the selected contract. It does not prove production performance, universal applicability, or a later market outcome.
Failure modes and misuse
- A correct ratio can still be distorted by acquisitions, disposals, seasonality, inflation, foreign exchange, restatements, or classification choices.
- Cross-company comparison requires the same framework, consolidation scope, period length, mapping, and analytical definition.
- Ratios summarize reported accounting amounts; they do not measure market value, forecast cash flows, or establish investment merit.
Debugging order
When a result looks surprising, inspect the state in this order:
- Confirm entity, period, framework, currency, and scale
- Trace every source fact and sign
- Recompute averages and bridges independently
- Check denominator state and selected variant
- Compare unrounded Python and TypeScript output
Evidence and historical boundary
Historical decision: not useful. A named issuer is not useful for the canonical arithmetic because filings mix company-specific labels, fiscal calendars, tax effects, lease policies, segments, and non-GAAP reconciliations. Primary filings are used to prove definition variability; controlled synthetic statements isolate the mechanism without implying a company judgment.
The primary sources are SEC non-GAAP C&DIs, IFRS 16, IAS 7, Issuer net-debt reconciliation example. They support the source roles listed in the research ledger. They do not supply a redistributable filed/provider observation or support investment quality, solvency, valuation, peer superiority, forecast accuracy, profitability, covenant compliance, or investment advice.
Practical questions and review answers
What exactly does this package calculate?
Canonical net debt = current borrowings + noncurrent borrowings + optionally lease liabilities − cash and cash equivalents; EBITDA bridges from net income by adding interest, tax, and D&A. The structured output includes components, state, and a reason code; the headline ratio is never the whole audit record.
Why can a filing platform, data vendor, spreadsheet, or company presentation disagree?
They may map different statement rows, fiscal periods, signs, tax or lease policies, cash definitions, balance averages, or non-GAAP adjustments. Compare the definition and evidence ledger before comparing numbers.
Can quarterly, trailing-twelve-month, and annual facts be mixed?
No. A flow numerator and every flow denominator must cover the declared period, while balance-sheet stocks must come from the matching boundary dates. Annualization is a separate, declared analytical transformation.
When should the output be null rather than zero or infinity?
At the material boundary: net debt equals zero or EBITDA becomes nonpositive. Null means the package cannot answer its stated question under that denominator state; it is not a missing cosmetic value.
Does a negative, high, or improving result automatically mean good or bad?
No. Interpretation depends on sector, business model, accounting framework, period, definition, and the components that moved. The ratio can describe a state without establishing its cause or desirability.
Does passing the code and parity checks prove investment value?
No. It proves that Python, TypeScript, fixtures, visuals, and the playground implement the frozen definition. It does not establish forecasting ability, solvency, covenant compliance, valuation, peer superiority, or investment suitability.
Where can I inspect every scenario rather than one example?
Open the guided 427-state playground and the full scenario-results.json.
Summary and next topic
You can now carry the result from Net-Debt/EBITDA into Common-Size Statements only after carrying forward the fact-mapping ledger and resetting method-specific numerator and denominator choices. The learning flow is: Interest-Coverage Ratio → Net-Debt/EBITDA → Common-Size Statements. Carry the result forward only with its scope, clock, state, and evidence label.
Rendered from the canonical Mermaid sources linked by this article.
Net-Debt/EBITDA calculation flow
This flow identifies the selected calculation stages and the structured output.
Takeaway: A leverage multiple is the last line of two reconciliations. Lease policy and eligible cash can move the numerator before EBITDA choices move the denominator.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
S1 — Non-GAAP Financial Measures: Compliance and Disclosure Interpretations
- Organization or authors: SEC Division of Corporation Finance
- Source type: Official regulator staff guidance
- Publication or effective date: 2022-12-13
- Version: Questions 100, 102, and 103
- URL or DOI: https://www.sec.gov/rules-regulations/staff-guidance/corporation-finance-interpretations/non-gaap-financial-measures
- Accessed: 2026-08-04
- Jurisdiction: United States securities disclosure
- Supports: Non-GAAP measures can mislead when labels or adjustments obscure their construction; SEC staff describes EBIT and EBITDA from GAAP net income and expects reconciliation and comparable-GAAP prominence.
- Limitations: It governs issuer disclosure; it does not make one analytical ROIC, net-debt, or leverage convention universal.
S2 — IFRS 16 Leases
- Organization or authors: International Accounting Standards Board
- Source type: Official accounting-standard overview
- Publication or effective date: 2016-01
- Version: Effective 2019-01-01
- URL or DOI: https://www.ifrs.org/issued-standards/list-of-standards/ifrs-16-leases/
- Accessed: 2026-08-04
- Jurisdiction: IFRS reporting
- Supports: Lessees generally recognize a right-of-use asset and lease liability, making lease inclusion a material leverage-definition choice.
- Limitations: It does not prescribe a net-debt or net-leverage ratio, and exemptions and issuer facts still matter.
S3 — IAS 7 Statement of Cash Flows
- Organization or authors: International Accounting Standards Board
- Source type: Official accounting standard
- Publication or effective date: 2021 issued compilation
- Version: IAS 7 paragraphs 6-9
- URL or DOI: https://www.ifrs.org/content/dam/ifrs/publications/pdf-standards/english/2021/issued/part-a/ias-7-statement-of-cash-flows.pdf
- Accessed: 2026-08-04
- Jurisdiction: IFRS reporting
- Supports: Cash equivalents are short-term, highly liquid investments convertible to known cash with insignificant value-change risk and are held for short-term commitments.
- Limitations: Restricted cash, overdrafts, marketable securities, and analytical net-debt policies require separate mapping decisions.
S4 — Reconciliation of Debt and Net Income to Net Debt and Adjusted EBITDA
- Organization or authors: Issuer exhibit hosted by SEC EDGAR
- Source type: Filed non-GAAP reconciliation example
- Publication or effective date: 2022
- Version: SEC accession 0001157523-22-001566, Exhibit 99.1
- URL or DOI: https://www.sec.gov/Archives/edgar/data/719413/000115752322001566/a52964834_ex991.htm
- Accessed: 2026-08-04
- Jurisdiction: United States issuer disclosure
- Supports: The exhibit reconciles debt and earnings to issuer-defined net debt and adjusted EBITDA and illustrates that lease and adjustment choices must be read, not assumed.
- Limitations: The issuer's adjustments are not copied into this package and do not establish comparability with another issuer.
Evidence boundary
The sources establish the exact rule, interface, protocol, or research context named above. They do not verify the repository-authored synthetic fixture, thresholds, empirical usefulness, execution probability, or profitability. Package-selected choices remain labeled as implementation choices wherever they are used.
Full dependency-light reference implementations in both supported languages.
/** Deterministic TypeScript reference calculations for D18-F01. */
type Inputs = Record<string, unknown>;
type Output = Record<string, unknown>;
function numberValue(name: string, value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new RangeError(`${name} must be a finite number`);
return value;
}
function positive(name: string, value: unknown): number {
const parsed = numberValue(name, value);
if (parsed <= 0) throw new RangeError(`${name} must be positive`);
return parsed;
}
function booleanValue(name: string, value: unknown): boolean {
if (typeof value !== "boolean") throw new RangeError(`${name} must be boolean`);
return value;
}
const average = (beginning: number, ending: number): number => (beginning + ending) / 2;
const clean = (value: number): number => {
const rounded = Math.round((value + Number.EPSILON) * 1e12) / 1e12;
return Object.is(rounded, -0) ? 0 : rounded;
};
export function dupontDecomposition(input: Inputs): Output {
const income = numberValue("net_income", input.net_income);
const revenue = numberValue("revenue", input.revenue);
const averageAssets = average(numberValue("beginning_assets", input.beginning_assets), numberValue("ending_assets", input.ending_assets));
const averageEquity = average(numberValue("beginning_equity", input.beginning_equity), numberValue("ending_equity", input.ending_equity));
if (revenue === 0 || averageAssets <= 0 || averageEquity <= 0) {
const reasons: string[] = [];
if (revenue === 0) reasons.push("zero-revenue");
if (averageAssets <= 0) reasons.push("nonpositive-average-assets");
if (averageEquity <= 0) reasons.push("nonpositive-average-equity");
return { net_profit_margin: null, asset_turnover: null, equity_multiplier: null, dupont_roe: null, direct_roe: null, identity_gap: null,
average_assets: clean(averageAssets), average_equity: clean(averageEquity), state: "not-meaningful", reason: reasons.join("+") };
}
const margin = income / revenue;
const turnover = revenue / averageAssets;
const multiplier = averageAssets / averageEquity;
const dupontRoe = margin * turnover * multiplier;
const directRoe = income / averageEquity;
return { net_profit_margin: clean(margin), asset_turnover: clean(turnover), equity_multiplier: clean(multiplier), dupont_roe: clean(dupontRoe),
direct_roe: clean(directRoe), identity_gap: clean(dupontRoe - directRoe), average_assets: clean(averageAssets), average_equity: clean(averageEquity),
state: income < 0 ? "loss" : "calculated", reason: "three-factor-identity-reconciles" };
}
export function roicCalculation(input: Inputs): Output {
const profit = numberValue("operating_profit", input.operating_profit);
const taxRate = numberValue("normalized_tax_rate", input.normalized_tax_rate);
if (taxRate < 0 || taxRate > 1) throw new RangeError("normalized_tax_rate must be between 0 and 1");
const capitalBegin = numberValue("beginning_operating_assets", input.beginning_operating_assets) - numberValue("beginning_operating_liabilities", input.beginning_operating_liabilities);
const capitalEnd = numberValue("ending_operating_assets", input.ending_operating_assets) - numberValue("ending_operating_liabilities", input.ending_operating_liabilities);
const averageCapital = average(capitalBegin, capitalEnd);
const nopat = profit * (1 - taxRate);
if (averageCapital <= 0) return { nopat: clean(nopat), beginning_invested_capital: clean(capitalBegin), ending_invested_capital: clean(capitalEnd),
average_invested_capital: clean(averageCapital), roic: null, state: "not-meaningful", reason: "nonpositive-average-invested-capital" };
return { nopat: clean(nopat), beginning_invested_capital: clean(capitalBegin), ending_invested_capital: clean(capitalEnd),
average_invested_capital: clean(averageCapital), roic: clean(nopat / averageCapital), state: nopat < 0 ? "loss" : "calculated",
reason: "package-operating-capital-definition" };
}
export function cashConversionCycle(input: Inputs): Output {
const revenue = numberValue("revenue", input.revenue);
const cogs = numberValue("cost_of_goods_sold", input.cost_of_goods_sold);
const days = positive("day_count", input.day_count);
const receivablesBegin = numberValue("beginning_receivables", input.beginning_receivables);
const receivablesEnd = numberValue("ending_receivables", input.ending_receivables);
const inventoryBegin = numberValue("beginning_inventory", input.beginning_inventory);
const inventoryEnd = numberValue("ending_inventory", input.ending_inventory);
const payablesBegin = numberValue("beginning_trade_payables", input.beginning_trade_payables);
const payablesEnd = numberValue("ending_trade_payables", input.ending_trade_payables);
if (Math.min(receivablesBegin, receivablesEnd, inventoryBegin, inventoryEnd, payablesBegin, payablesEnd) < 0) throw new RangeError("working-capital balances must be nonnegative");
const receivables = average(receivablesBegin, receivablesEnd);
const inventory = average(inventoryBegin, inventoryEnd);
const payables = average(payablesBegin, payablesEnd);
if (revenue <= 0 || cogs <= 0) return { average_receivables: clean(receivables), average_inventory: clean(inventory), average_trade_payables: clean(payables),
days_sales_outstanding: null, days_inventory_outstanding: null, days_payables_outstanding: null, cash_conversion_cycle_days: null,
state: "not-meaningful", reason: revenue <= 0 ? "nonpositive-revenue" : "nonpositive-cogs" };
const dso = receivables / revenue * days;
const dio = inventory / cogs * days;
const dpo = payables / cogs * days;
const ccc = dio + dso - dpo;
return { average_receivables: clean(receivables), average_inventory: clean(inventory), average_trade_payables: clean(payables),
days_sales_outstanding: clean(dso), days_inventory_outstanding: clean(dio), days_payables_outstanding: clean(dpo),
cash_conversion_cycle_days: clean(ccc), state: ccc < 0 ? "negative-cycle" : "calculated", reason: "dio-plus-dso-minus-dpo" };
}
export function interestCoverageRatio(input: Inputs): Output {
const ebit = numberValue("ebit", input.ebit);
const interest = numberValue("gross_interest_expense", input.gross_interest_expense);
if (interest <= 0) return { ebit: clean(ebit), gross_interest_expense: clean(interest), interest_coverage_ratio: null,
coverage_shortfall: null, state: "not-meaningful", reason: "nonpositive-gross-interest-expense" };
const ratio = ebit / interest;
return { ebit: clean(ebit), gross_interest_expense: clean(interest), interest_coverage_ratio: clean(ratio),
coverage_shortfall: clean(Math.max(0, interest - ebit)), state: ratio >= 1 ? "covered" : "not-covered",
reason: "ebit-divided-by-gross-interest-expense" };
}
export function netDebtToEbitda(input: Inputs): Output {
const income = numberValue("net_income", input.net_income);
const interest = numberValue("interest_expense", input.interest_expense);
const tax = numberValue("income_tax_expense", input.income_tax_expense);
const da = numberValue("depreciation_and_amortization", input.depreciation_and_amortization);
const currentDebt = numberValue("current_borrowings", input.current_borrowings);
const noncurrentDebt = numberValue("noncurrent_borrowings", input.noncurrent_borrowings);
const leases = numberValue("lease_liabilities", input.lease_liabilities);
const cash = numberValue("cash_and_cash_equivalents", input.cash_and_cash_equivalents);
const includeLeases = booleanValue("include_lease_liabilities", input.include_lease_liabilities);
if (Math.min(interest, da, currentDebt, noncurrentDebt, leases, cash) < 0) throw new RangeError("debt, cash, interest, and D&A inputs must be nonnegative");
const ebitda = income + interest + tax + da;
const grossDebt = currentDebt + noncurrentDebt + (includeLeases ? leases : 0);
const netDebt = grossDebt - cash;
if (ebitda <= 0) return { ebitda: clean(ebitda), gross_debt: clean(grossDebt), net_debt: clean(netDebt), net_debt_to_ebitda: null,
lease_policy: includeLeases ? "included" : "excluded", state: "not-meaningful", reason: "nonpositive-ebitda" };
return { ebitda: clean(ebitda), gross_debt: clean(grossDebt), net_debt: clean(netDebt), net_debt_to_ebitda: clean(netDebt / ebitda),
lease_policy: includeLeases ? "included" : "excluded", state: netDebt < 0 ? "net-cash" : "net-debt", reason: "net-debt-divided-by-ebitda" };
}
interface Item { label: string; value: number }
interface Period { period: string; items: Item[] }
export function commonSizeStatements(input: Inputs): Output {
const statementType = input.statement_type;
if (statementType !== "income" && statementType !== "balance") throw new RangeError("statement_type must be 'income' or 'balance'");
const baseLabel = input.base_label;
const focusLabel = input.focus_label;
if (typeof baseLabel !== "string" || !baseLabel.trim()) throw new RangeError("base_label must be a nonempty string");
if (typeof focusLabel !== "string" || !focusLabel.trim()) throw new RangeError("focus_label must be a nonempty string");
if (!Array.isArray(input.periods) || input.periods.length < 2) throw new RangeError("periods must contain at least two period objects");
let expectedLabels: string[] | null = null;
let previousFocus: number | null = null;
const normalizedPeriods = (input.periods as unknown[]).map((rawPeriod, periodIndex) => {
if (!rawPeriod || typeof rawPeriod !== "object") throw new RangeError(`periods[${periodIndex}] must be an object`);
const period = rawPeriod as Record<string, unknown>;
if (typeof period.period !== "string" || !period.period.trim()) throw new RangeError(`periods[${periodIndex}].period must be a nonempty string`);
if (!Array.isArray(period.items) || period.items.length < 4) throw new RangeError(`periods[${periodIndex}].items must contain at least four rows`);
const seen = new Set<string>();
const parsed = (period.items as unknown[]).map((rawItem, itemIndex): Item => {
if (!rawItem || typeof rawItem !== "object") throw new RangeError(`periods[${periodIndex}].items[${itemIndex}] must be an object`);
const item = rawItem as Record<string, unknown>;
if (typeof item.label !== "string" || !item.label.trim() || seen.has(item.label)) throw new RangeError("statement item labels must be nonempty and unique within a period");
seen.add(item.label);
return { label: item.label, value: numberValue(`${period.period}.${item.label}`, item.value) };
});
const labels = parsed.map((item) => item.label);
if (expectedLabels === null) expectedLabels = labels;
else if (labels.join("\u0000") !== expectedLabels.join("\u0000")) throw new RangeError("every period must use the same ordered line-item labels");
const values = Object.fromEntries(parsed.map((item) => [item.label, item.value]));
if (!(baseLabel in values) || !(focusLabel in values)) throw new RangeError("base_label and focus_label must exist in every period");
const base = values[baseLabel];
if (base === 0 || (statementType === "balance" && base < 0)) throw new RangeError("the common-size base must be nonzero and positive for balance sheets");
const focusPercentage = values[focusLabel] / base * 100;
const result = { period: (period.period as string).trim(), base_value: clean(base), focus_percentage: clean(focusPercentage),
focus_change_pp: previousFocus === null ? null : clean(focusPercentage - previousFocus),
items: parsed.map((item) => ({ label: item.label, value: clean(item.value), common_size_pct: clean(item.value / base * 100) })) };
previousFocus = focusPercentage;
return result;
});
const latest = normalizedPeriods[normalizedPeriods.length - 1];
return { statement_type: statementType, base_label: baseLabel, focus_label: focusLabel, period_count: normalizedPeriods.length,
line_item_count: normalizedPeriods[0].items.length, focus_percentage: latest.focus_percentage, focus_change_pp: latest.focus_change_pp,
periods: normalizedPeriods, state: "calculated", reason: "signed-line-items-divided-by-period-base" };
}
export function calculate(topicId: string, input: Inputs): Output {
if (!input || typeof input !== "object" || Array.isArray(input)) throw new RangeError("inputs must be an object");
switch (topicId) {
case "D18-F01-A01": return dupontDecomposition(input);
case "D18-F01-A02": return roicCalculation(input);
case "D18-F01-A03": return cashConversionCycle(input);
case "D18-F01-A04": return interestCoverageRatio(input);
case "D18-F01-A05": return netDebtToEbitda(input);
case "D18-F01-A06": return commonSizeStatements(input);
default: throw new RangeError(`unsupported topic_id: ${topicId}`);
}
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.