Enterprise Value Is Easy Until You Read the Filing
Published March 21, 2019
Enterprise value is usually presented as arithmetic:
In a live stock screener, enterprise value is a temporal join. Price changes continuously. Shares outstanding change on corporate actions and periodic disclosures. Debt and cash usually change when a filing becomes public. Lease liabilities are entering balance sheets under ASC 842. A correct enterprise value calculation has to specify which facts were available, when they were available, and which valuation policy is being applied.
Point-in-time EV uses only public facts. Backfilled EV applies a later lease-liability disclosure before its filing date. Values are synthetic and shown in billions of dollars.
The formula is not the difficult part. Timestamp semantics, taxonomy normalization, and denominator consistency are the difficult parts.
Enterprise value needs a policy, not one hard-coded formula
A practical enterprise value formula is:
Where:
- is the share price at time .
- is the applicable share count.
- is interest-bearing debt.
- is the lease liability included by the selected policy.
- is preferred stock.
- is noncontrolling interest.
- is cash and cash equivalents, optionally including eligible short-term investments.
There is no universally correct setting for every term. A credit screen, an acquisition screen, and an EV/EBITDA screen can require different treatment. The calculation therefore needs named policies.
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
ZERO = Decimal("0")
class LeasePolicy(Enum):
EXCLUDE_OPERATING = "exclude_operating"
INCLUDE_OPERATING = "include_operating"
@dataclass(frozen=True)
class EVPolicy:
lease_policy: LeasePolicy
include_preferred_stock: bool = True
include_noncontrolling_interest: bool = True
include_short_term_investments: bool = True
include_incremental_dilution: bool = False
@dataclass(frozen=True)
class CapitalStructure:
shares_outstanding: Decimal
incremental_dilutive_shares: Decimal
short_term_debt: Decimal
current_long_term_debt: Decimal
long_term_debt: Decimal
finance_lease_liability: Decimal
operating_lease_liability: Decimal
preferred_stock: Decimal
noncontrolling_interest: Decimal
cash_and_equivalents: Decimal
short_term_investments: Decimal
def enterprise_value(
price: Decimal,
capital: CapitalStructure,
policy: EVPolicy,
) -> Decimal:
shares = capital.shares_outstanding
if policy.include_incremental_dilution:
shares += capital.incremental_dilutive_shares
equity_value = price * shares
debt = (
capital.short_term_debt
+ capital.current_long_term_debt
+ capital.long_term_debt
+ capital.finance_lease_liability
)
if policy.lease_policy is LeasePolicy.INCLUDE_OPERATING:
debt += capital.operating_lease_liability
additions = debt
if policy.include_preferred_stock:
additions += capital.preferred_stock
if policy.include_noncontrolling_interest:
additions += capital.noncontrolling_interest
cash_offset = capital.cash_and_equivalents
if policy.include_short_term_investments:
cash_offset += capital.short_term_investments
return equity_value + additions - cash_offset
This function is intentionally explicit. An enterprise value engine should not infer accounting policy from whichever XBRL tags happen to be present.
Shares outstanding are not diluted weighted-average shares
The most common share-count error is substituting diluted weighted-average shares from the income statement for shares outstanding used in market capitalization.
These fields answer different questions:
- Shares outstanding is a point-in-time balance. It belongs in market capitalization.
- Basic weighted-average shares is a duration fact used for basic earnings per share.
- Diluted weighted-average shares is a duration fact used for diluted earnings per share.
Using diluted weighted-average shares in market capitalization mixes an average over the reporting period with a price observed on one date. Buybacks, issuances, option exercises, and conversions make the error material.
A screener that wants fully diluted enterprise value should model incremental claims separately. It should not rename diluted weighted-average shares as shares outstanding.
from decimal import Decimal
def treasury_stock_incremental_shares(
options: Decimal,
strike_price: Decimal,
market_price: Decimal,
) -> Decimal:
if options <= 0 or market_price <= 0:
return Decimal("0")
if strike_price >= market_price:
return Decimal("0")
assumed_proceeds = options * strike_price
shares_repurchasable = assumed_proceeds / market_price
return options - shares_repurchasable
incremental = treasury_stock_incremental_shares(
options=Decimal("12000000"),
strike_price=Decimal("18.50"),
market_price=Decimal("31.00"),
)
print(incremental) # 4,838,709.677...
The treasury stock method is still an approximation. Convertible debt, restricted stock units, performance awards, and capped calls require separate rules. The important design decision is to preserve the basic reported share count and add dilution as a named scenario.
ASC 842 makes lease treatment impossible to ignore
For public business entities, ASC 842 is effective for fiscal years beginning after December 15, 2018. Operating lease obligations that were previously concentrated in footnotes are moving onto the balance sheet as lease liabilities with corresponding right-of-use assets.
That does not mean every operating lease liability should be added mechanically to every enterprise value.
If operating lease liabilities are added to enterprise value while the denominator remains ordinary EBITDA, the multiple may lose comparability with periods and companies where rent remains an operating expense. A lease-adjusted numerator should be paired with a lease-consistent denominator, such as an explicitly adjusted EBITDA or EBITDAR calculation.
The screener should expose at least two policies:
| Policy | Numerator | Suitable denominator |
|---|---|---|
| Reported EV | Borrowings plus finance leases | Reported EBITDA or operating income |
| Lease-adjusted EV | Reported EV plus operating lease liabilities | Lease-adjusted EBITDA, EBITDAR, or a cash-flow measure with matching treatment |
The rule is not that operating leases are always debt or never debt. The rule is that numerator and denominator must use the same economic model.
SEC facts have at least three dates
A balance-sheet fact needs more than a value and a period end. Store at least:
- Period end: the date represented by the financial statement.
- Accepted timestamp: when the SEC accepted the filing.
- Observation timestamp: when the ingestion system received and normalized it.
For a historical screen on February 15, a December 31 cash balance filed on March 1 is not available. Backfilling the March filing to December 31 introduces future information into enterprise value.
The as-of selection rule is simple:
The implementation has to apply that rule before calculating enterprise value.
from bisect import bisect_right
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import List, Optional, Sequence, Tuple
@dataclass(frozen=True)
class FilingSnapshot:
period_end: date
accepted_at: datetime
accession_number: str
capital: CapitalStructure
def build_acceptance_index(
snapshots: Sequence[FilingSnapshot],
) -> Tuple[List[datetime], List[FilingSnapshot]]:
ordered = sorted(snapshots, key=lambda row: row.accepted_at)
return [row.accepted_at for row in ordered], ordered
def latest_public_snapshot(
acceptance_times: Sequence[datetime],
ordered_snapshots: Sequence[FilingSnapshot],
as_of: datetime,
) -> Optional[FilingSnapshot]:
index = bisect_right(acceptance_times, as_of) - 1
if index < 0:
return None
available = ordered_snapshots[: index + 1]
return max(
available,
key=lambda row: (row.period_end, row.accepted_at),
)
This index works when each accepted filing produces one normalized capital-structure snapshot. If amendments and overlapping forms are stored independently, add a resolution step that prefers the latest accepted filing for the latest fiscal period available at the as-of timestamp.
Do not use the filing’s period end as the availability date. Do not use the date on which a data vendor happened to refresh a field as the economic period. Both shortcuts destroy reproducibility.
Normalize debt components before summing them
SEC XBRL filings do not guarantee one canonical debt tag. One issuer may report current maturities separately. Another may report a total carrying amount. A third may use an extension tag. Adding every field that contains the word Debt will double count liabilities.
Normalize source facts into mutually exclusive canonical fields:
from dataclasses import dataclass
from decimal import Decimal
from typing import Mapping, Optional
@dataclass(frozen=True)
class CanonicalDebt:
short_term_borrowings: Decimal
current_portion_long_term_debt: Decimal
long_term_debt_noncurrent: Decimal
finance_lease_current: Decimal
finance_lease_noncurrent: Decimal
@property
def total(self) -> Decimal:
return (
self.short_term_borrowings
+ self.current_portion_long_term_debt
+ self.long_term_debt_noncurrent
+ self.finance_lease_current
+ self.finance_lease_noncurrent
)
def first_present(
facts: Mapping[str, Decimal],
*names: str,
) -> Optional[Decimal]:
for name in names:
if name in facts:
return facts[name]
return None
def normalize_debt(facts: Mapping[str, Decimal]) -> CanonicalDebt:
total_current = first_present(
facts,
"ShortTermBorrowings",
"ShortTermDebtCurrent",
) or Decimal("0")
current_ltd = first_present(
facts,
"LongTermDebtCurrent",
"CurrentMaturitiesOfLongTermDebt",
) or Decimal("0")
long_term = first_present(
facts,
"LongTermDebtNoncurrent",
"LongTermDebtAndFinanceLeaseObligationsNoncurrent",
) or Decimal("0")
finance_current = facts.get(
"FinanceLeaseLiabilityCurrent",
Decimal("0"),
)
finance_noncurrent = facts.get(
"FinanceLeaseLiabilityNoncurrent",
Decimal("0"),
)
return CanonicalDebt(
short_term_borrowings=total_current,
current_portion_long_term_debt=current_ltd,
long_term_debt_noncurrent=long_term,
finance_lease_current=finance_current,
finance_lease_noncurrent=finance_noncurrent,
)
A production normalizer also needs context filtering, unit normalization, dimensional checks, extension-tag mappings, and issuer-specific overrides. The canonical model should still remain small. Complexity belongs in the mapping layer, not in the enterprise value formula.
Cash subtraction also requires a policy
Subtracting all cash-like assets is another common source of false precision.
Cash and cash equivalents are usually included. Short-term investments may be included if they are liquid and not operationally restricted. Restricted cash should normally be excluded from the offset. Customer funds, regulatory deposits, and cash trapped in consolidated special-purpose entities may not be available to an acquirer or creditor.
A useful default is:
The word eligible matters. Store the gross facts and the policy decision separately so the fair valuation engine can produce alternate cases without re-ingesting filings.
Validate the result as a data product
A stock screener should reject implausible enterprise value records before they enter rankings. Useful invariants include:
from decimal import Decimal
def validate_enterprise_value_inputs(
price: Decimal,
capital: CapitalStructure,
) -> None:
if price <= 0:
raise ValueError("price must be positive")
if capital.shares_outstanding <= 0:
raise ValueError("shares outstanding must be positive")
nonnegative_fields = {
"incremental_dilutive_shares": capital.incremental_dilutive_shares,
"short_term_debt": capital.short_term_debt,
"current_long_term_debt": capital.current_long_term_debt,
"long_term_debt": capital.long_term_debt,
"finance_lease_liability": capital.finance_lease_liability,
"operating_lease_liability": capital.operating_lease_liability,
"preferred_stock": capital.preferred_stock,
"noncontrolling_interest": capital.noncontrolling_interest,
"cash_and_equivalents": capital.cash_and_equivalents,
"short_term_investments": capital.short_term_investments,
}
for name, value in nonnegative_fields.items():
if value < 0:
raise ValueError("{} must be nonnegative".format(name))
def relative_change(current: Decimal, previous: Decimal) -> Decimal:
if previous == 0:
return Decimal("Infinity")
return abs(current - previous) / abs(previous)
Large changes are not automatically errors. They are review candidates. A 40 percent change in enterprise value can come from an acquisition, a debt issuance, a buyback, a new lease-liability presentation, a unit error, or duplicate debt tags. The pipeline should retain the inputs, filing accession number, policy name, and calculation trace needed to explain the move.
At minimum, log:
- Price timestamp and source.
- Share-count fact and context.
- Filing accepted timestamp.
- Debt components before aggregation.
- Cash components before subtraction.
- Lease policy.
- Dilution policy.
- Enterprise value result.
A ranking without this trace is not auditable.
The correct model is an as-of valuation state
Enterprise value should be stored as a derived observation, not as a permanent company attribute. The useful key is close to:
(company_id, market_timestamp, filing_accepted_at, valuation_policy_version)
That key allows the same issuer and date to have a reported EV, a lease-adjusted EV, and a fully diluted EV without overwriting source facts. It also makes historical results reproducible when taxonomy mappings or policy decisions change.
The broader engineering rule is straightforward: market data and fundamental data do not share a clock. A fundamental stock screener that joins them without explicit as-of semantics will produce precise numbers with incorrect information sets.