Financial Ratios Need a Type System
November 21, 2019
Most stock screeners store every financial value in the same machine type: a floating-point number. That decision makes invalid formulas executable.
The expression below looks complete:
ev_to_ebitda = (price * shares + debt - cash) / ebitda
It omits the facts that determine whether the result means anything. The price has a timestamp and quote currency. The share count has a basis. Debt has a consolidation scope and an accounting policy. Cash may include restricted balances. EBITDA covers a period and depends on a definition. A float carries none of those constraints.
Figure 1. Common ratios that appear dimensionless still require compatibility checks across units, temporal shape, observation window, currency, scope, and capital basis.
Dimensionless does not mean typeless
A ratio can reduce to a plain number under unit algebra and remain financially invalid.
Consider enterprise value divided by EBITDA. Both inputs are measured in currency, so the currency unit cancels. That cancellation proves only that the arithmetic result is dimensionless. It does not prove that the numerator and denominator describe the same business, the same currency basis, or a compatible time boundary.
A production EV to EBITDA calculation needs at least these conditions:
- Enterprise value is an instant value at a specified market timestamp.
- EBITDA is a flow over an explicit interval.
- The EBITDA interval ends near the enterprise-value timestamp.
- The interval represents a trailing year, or the formula records an annualization policy.
- Both values use the same reporting currency or an explicit translation basis.
- Debt, cash, minority interest, preferred equity, leases, and pension obligations follow one capital policy.
- EBITDA uses a scope compatible with that capital policy.
The number 8.4 contains no evidence that those conditions held.
This is the central defect in most financial ratio calculation code. Formula authors use comments and variable names as a substitute for types. The execution engine receives scalar values after the important distinctions have already been discarded.
Financial values have several independent types
A useful type system for fundamental data needs more than int, float, and Decimal. Each quantity carries a product type assembled from several dimensions.
Unit
The basic algebraic unit can be currency, shares, currency per share, or a dimensionless value. Multiplication and division transform unit exponents mechanically.
Market capitalization illustrates the simple case:
currency/share × shares = currency
The unit check catches several common failures. A split-adjusted price multiplied by an unadjusted share count has compatible algebraic units and incompatible share bases, so unit algebra remains necessary but insufficient.
Temporal shape
Balance-sheet facts are stocks observed at an instant. Income-statement and cash-flow facts are flows accumulated over an interval. Market prices are instant observations. Weighted-average shares are period measures even though their unit is shares.
The distinction determines valid operators. Adding two flows requires comparable intervals. Averaging two instant values can produce a denominator for return on equity. Dividing a quarterly flow by an ending balance produces a quarterly return unless an annualization step is explicit.
Observation window
Two flows with the same end date can cover different durations. A quarterly revenue fact and a year-to-date revenue fact often coexist in the same filing. Both are valid, both use the same currency, and both may share the same taxonomy concept.
Duration alone also fails to establish comparability. A 364-day retailer year and a 365-day calendar year can be economically comparable. A 53-week year needs explicit treatment in growth calculations. The type must preserve actual boundaries, not a label such as FY or TTM.
Currency basis
Currency is more than an ISO code. Historical-cost balance-sheet values, average-rate income-statement values, and spot market values can all be expressed in USD while embedding different translation conventions.
For a single issuer, an EV to revenue ratio usually tolerates the reporting convention because numerator and denominator are close in time and reported in the same currency. Cross-company aggregation, factor construction, and multi-currency valuation require a declared FX policy.
Consolidation scope
A filing can contain consolidated facts, parent-only facts, segment facts, continuing operations, discontinued operations, and pro forma acquisition disclosures. The XBRL concept and unit can match while the economic perimeter differs.
Scope errors are unusually dangerous because the result often looks plausible. Consolidated debt divided by parent-only EBITDA can produce a multiple near the peer median. Plausibility is not validation.
Capital and share basis
Per-share values require a compatible denominator basis. Basic weighted-average shares, diluted weighted-average shares, period-end shares outstanding, and treasury-stock-method dilution answer different questions.
Capital ratios carry similar policy choices. Enterprise value may include operating lease liabilities, finance leases, preferred equity, noncontrolling interests, unfunded pensions, securitization debt, or none of them. EBITDA must be adjusted under the same policy. A formula engine needs the policy in the type or in the operator contract.
Represent the type before the value
The type metadata should survive ingestion. Reconstructing it at formula time is too late because upstream normalization may already have merged incompatible facts.
The following model is deliberately small. It handles unit exponents, temporal shape, currency, boundaries, scope, and basis. It uses Python 3.8 syntax and Decimal for the stored scalar.
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Optional, Tuple
class Shape(Enum):
INSTANT = "instant"
FLOW = "flow"
PERIOD_COUNT = "period_count"
PRICE = "price"
RATIO = "ratio"
@dataclass(frozen=True)
class Unit:
currency: int = 0
shares: int = 0
def __mul__(self, other: "Unit") -> "Unit":
return Unit(
currency=self.currency + other.currency,
shares=self.shares + other.shares,
)
def __truediv__(self, other: "Unit") -> "Unit":
return Unit(
currency=self.currency - other.currency,
shares=self.shares - other.shares,
)
MONEY = Unit(currency=1)
SHARES = Unit(shares=1)
PRICE_PER_SHARE = MONEY / SHARES
DIMENSIONLESS = Unit()
@dataclass(frozen=True)
class MetricType:
unit: Unit
shape: Shape
currency: Optional[str] = None
period: Optional[Tuple[date, date]] = None
as_of: Optional[date] = None
scope: str = "consolidated"
basis: Optional[str] = None
def __post_init__(self) -> None:
has_period = self.period is not None
has_instant = self.as_of is not None
if self.shape in (Shape.FLOW, Shape.PERIOD_COUNT) and not has_period:
raise ValueError("period value requires start and end dates")
if self.shape in (Shape.INSTANT, Shape.PRICE) and not has_instant:
raise ValueError("instant value requires an as-of date")
if has_period and has_instant:
raise ValueError("a value cannot be both instant and duration")
if self.unit.currency and self.currency is None:
raise ValueError("monetary unit requires a currency")
@property
def duration_days(self) -> Optional[int]:
if self.period is None:
return None
start, end = self.period
return (end - start).days
@dataclass(frozen=True)
class Quantity:
value: Decimal
type: MetricType
source_id: str
source_id is part of the quantity because a computed value without provenance cannot be audited. In a real system it would identify the filing, fact, transformation chain, and formula version. The scalar remains the least interesting field.
This representation also prevents a frequent XBRL ingestion error. The XBRL unit describes dimensional units such as USD, shares, or USD per share. It does not encode the period, scope, share basis, or accounting meaning. The decimals attribute describes reported accuracy. It should not be treated as a request to round the normalized value again.
Operators need contracts
General arithmetic can infer unit exponents. Financial operators need stricter contracts.
An EV to EBITDA operator should reject quarterly EBITDA unless the caller explicitly annualizes it. Silent multiplication by four is a model assumption. It fails for seasonality, acquisitions, fiscal calendars, and 53-week years. The assumption belongs in a named transformation with provenance.
from decimal import Decimal
from typing import NoReturn
class FormulaError(ValueError):
pass
def fail(formula: str, message: str) -> NoReturn:
raise FormulaError("{}: {}".format(formula, message))
def require_same_currency(formula: str, left: Quantity, right: Quantity) -> None:
if left.type.currency != right.type.currency:
fail(
formula,
"currency mismatch: {} versus {}".format(
left.type.currency,
right.type.currency,
),
)
def require_same_scope(formula: str, left: Quantity, right: Quantity) -> None:
if left.type.scope != right.type.scope:
fail(
formula,
"scope mismatch: {} versus {}".format(
left.type.scope,
right.type.scope,
),
)
def ev_to_ebitda(ev: Quantity, ebitda: Quantity) -> Quantity:
formula = "ev_to_ebitda"
if ev.type.shape is not Shape.INSTANT:
fail(formula, "enterprise value must be an instant value")
if ebitda.type.shape is not Shape.FLOW:
fail(formula, "EBITDA must be a flow")
if ev.type.unit != MONEY or ebitda.type.unit != MONEY:
fail(formula, "both operands must have currency units")
require_same_currency(formula, ev, ebitda)
require_same_scope(formula, ev, ebitda)
start, end = ebitda.type.period # validated by MetricType
duration = (end - start).days
if not 350 <= duration <= 380:
fail(
formula,
"EBITDA must cover a trailing year; received {} days".format(duration),
)
if ev.type.as_of != end:
fail(
formula,
"enterprise value date {} does not match EBITDA end date {}".format(
ev.type.as_of,
end,
),
)
if ebitda.value == 0:
fail(formula, "EBITDA is zero")
return Quantity(
value=ev.value / ebitda.value,
type=MetricType(
unit=DIMENSIONLESS,
shape=Shape.RATIO,
as_of=end,
scope=ev.type.scope,
basis="enterprise_value_policy_v3",
),
source_id="{}:{}".format(ev.source_id, ebitda.source_id),
)
This operator is intentionally severe. It rejects a mismatched end date instead of applying a tolerance. A production system can expose a separate align_market_date transformation that chooses the nearest prior trading close, records the gap, and returns a new typed quantity. Keeping the repair explicit makes the formula reproducible.
The return value also carries a basis. Two vendors can both publish EV to EBITDA and disagree because one capitalizes operating leases while the other excludes them. The difference belongs in the formula identity, not in an undocumented data-cleaning step.
Formula graphs should compile before they execute
A stock screener may evaluate hundreds of formulas across tens of thousands of securities and many historical dates. Rechecking the same structural conditions for every scalar wastes work. The formula graph should compile into a typed execution plan.
Compilation has four useful stages:
- Parse formula declarations into an immutable expression graph.
- Infer units and required metadata from operator signatures.
- Reject structurally impossible formulas before reading values.
- Execute the valid plan against issuer-specific facts, reporting data-dependent type failures separately.
The expression graph also enables common-subexpression elimination. Market capitalization may feed enterprise value, free-cash-flow yield, price-to-sales, and dozens of diagnostics. Compute it once per observation key.
from dataclasses import dataclass
from functools import lru_cache
from typing import Callable, Dict, Tuple, Union
@dataclass(frozen=True)
class Ref:
name: str
@dataclass(frozen=True)
class Call:
operator: str
arguments: Tuple["Expression", ...]
Expression = Union[Ref, Call]
Operator = Callable[..., Quantity]
def ref(name: str) -> Ref:
return Ref(name)
def call(operator: str, *arguments: Expression) -> Call:
return Call(operator, tuple(arguments))
FORMULAS = {
"market_cap": call("multiply", ref("price"), ref("diluted_shares")),
"enterprise_value": call(
"subtract",
call(
"add",
call("multiply", ref("price"), ref("diluted_shares")),
ref("net_debt"),
),
ref("non_operating_assets"),
),
"ev_to_ebitda": call(
"ev_to_ebitda",
call(
"subtract",
call(
"add",
call("multiply", ref("price"), ref("diluted_shares")),
ref("net_debt"),
),
ref("non_operating_assets"),
),
ref("ebitda_ttm"),
),
}
class Executor:
def __init__(
self,
facts: Dict[str, Quantity],
operators: Dict[str, Operator],
) -> None:
self.facts = facts
self.operators = operators
@lru_cache(maxsize=None)
def evaluate(self, expression: Expression) -> Quantity:
if isinstance(expression, Ref):
try:
return self.facts[expression.name]
except KeyError:
raise FormulaError("missing fact: {}".format(expression.name))
operator = self.operators[expression.operator]
arguments = tuple(self.evaluate(arg) for arg in expression.arguments)
return operator(*arguments)
def evaluate_all(self) -> Dict[str, Quantity]:
return {
name: self.evaluate(expression)
for name, expression in FORMULAS.items()
}
The repeated market-capitalization and enterprise-value subtrees are structurally equal because the expression nodes are immutable and hashable. lru_cache turns the expression tree into a demand-driven DAG without a separate graph library.
A larger engine should compile operator dispatch and field access into compact instructions. The semantic graph remains the source of truth. Bytecode, vectorized arrays, or generated SQL are execution targets.
Invalid, missing, and undefined are different results
A ratio engine needs a result algebra, not a nullable float column.
These outcomes have different meanings:
- Missing: a required fact was not reported or could not be mapped.
- Invalid: available facts violate the formula contract.
- Undefined: the mathematical operation has no defined result, such as division by zero.
- Not applicable: the formula does not describe the issuer, such as inventory turnover for a business with no inventory.
- Stale: the value is computable but exceeds a freshness policy.
- Conflict: multiple candidate facts survive selection with no deterministic winner.
Collapsing all six states to NULL damages ranking, diagnostics, and user trust. Collapsing them to zero is worse.
A screener should expose the state alongside the value. Sorting can then place invalid values after valid values without pretending they are economically low. A valuation engine can refuse to produce a fair-value estimate when a critical input is stale while still displaying noncritical diagnostics.
Ratio definitions require versioning
Formula meaning changes over time even when the name remains stable.
Lease accounting provides a current example. A capital policy that ignores operating leases can be internally consistent under one reporting regime and misleading under another. Updating enterprise value without updating EBITDA creates a discontinuity in the multiple. Updating both changes historical comparability.
The formula identifier should therefore include a versioned policy package:
- debt classification policy
- cash and non-operating asset policy
- lease policy
- pension policy
- minority-interest policy
- preferred-equity policy
- earnings scope
- share basis
- currency policy
- period-alignment policy
A stored ratio should reference the policy version, input fact identifiers, and engine version. Recalculation then becomes a controlled migration rather than an unexplained data revision.
This also separates two valid products. A historical-as-reported screener preserves the definitions and facts available at each date. A current-normalized screener applies today’s policy across history. Both are useful. Mixing them in one column is not.
Type checking belongs near ingestion and near execution
One checker is insufficient.
Ingestion-time validation catches malformed facts before they enter the canonical store. Examples include an instant fact with a duration, a monetary unit without a currency, a per-share concept reported in plain currency, or a duplicate context with incompatible values.
Execution-time validation checks relationships among otherwise valid facts. Revenue and cost of revenue can each pass ingestion and still have mismatched periods. Price and diluted shares can each be valid and still use incompatible adjustment bases. Enterprise value and EBITDA can each be valid and still represent different consolidation scopes.
The two layers should report different error classes. Data-quality teams need to distinguish a malformed source fact from a valid fact that cannot satisfy a formula contract.
Static types still help in a dynamic data system
Financial metadata arrives at runtime, so the strongest guarantees also occur at runtime. Static Python types remain useful for the engine’s own interfaces.
A static checker can verify that every operator accepts and returns Quantity, that formula nodes are immutable, and that execution states are handled. The domain checker then validates the financial type carried inside each quantity.
This split is practical:
- Python’s type checker protects the implementation.
- The formula compiler protects the expression graph.
- Runtime contracts protect issuer-specific facts.
- Provenance protects reproducibility.
Trying to encode every taxonomy concept and accounting policy in Python’s static type system would produce an unusable program. Treat financial types as data with a small, strict interpreter.
The type system becomes the valuation engine
Screening and valuation share the same dependency graph.
A discounted cash-flow model introduces additional type dimensions:
- nominal versus real cash flows
- pre-tax versus after-tax values
- levered versus unlevered cash flows
- local-currency versus reporting-currency forecasts
- discrete versus continuous compounding
- annual, quarterly, or irregular discount periods
- enterprise-value versus equity-value terminal outputs
A discount rate is dimensionless under unit algebra. It is still incompatible with many cash-flow streams. A nominal weighted average cost of capital cannot discount real cash flows without an inflation transformation. A cost of equity cannot discount unlevered free cash flow. A quarterly rate cannot be applied to annual periods without a compounding policy.
These are type errors.
A fundamental stock screener that preserves type metadata can grow into a fair valuation engine without replacing its core. The formula graph gains forecast nodes, scenario parameters, and discount operators. The same compiler validates the plan, deduplicates shared subexpressions, records assumptions, and produces an auditable dependency trace.
Store the float last
The scalar value is an execution result. It should be stored after the engine has preserved the information needed to judge it.
A durable ratio record contains at least:
- computed value
- result state
- formula identifier and version
- unit and semantic type
- observation boundary
- input fact identifiers
- transformation identifiers
- policy package
- engine version
- computation timestamp
That record is larger than a float. It is also queryable, reproducible, and defensible.
The cost of a type system appears early. The benefit compounds with every new metric, every historical restatement, every currency conversion, every change in accounting policy, and every valuation model built on top of the same data.
A financial formula is a program. Compile it before trusting its output.